YownYang's blog

译《Effective Objective-C 2.0》第四章

这是翻译《Effective Objective-C 2.0》的第四章:协议和分类

简介

协议是Objective-C语言的一个功能,类似Java的接口。Objective-C没有多重继承,因此我们把某个类应该实现的方法定义在协议中。协议最多的用法是实现委托模式(看第23节),
但是它也有别的用法。了解并使用它们可以使代码更易于维护,因为这是记录代码接口的好方法。

分类也是Objective-C语言的一个重要功能。它提供了一种给类添加方法而不需要添加子类的机制,而在其他方法中则不行。由于运行时的高度动态性,这一特性成为可能,但它也有一些缺陷,你应该在使用之前了解这些缺陷。

通过委托与数据源协议进行对象间通信

对象之间经常需要通信。Objective-C开发者都知道一种叫做委托协议的设计模式。它的本质是定义一个接口,使任何遵循这个接口的类成为另一个类的委托(PS:为了便于理解,定义接口的类是委托类,遵循接口的类是被委托类)。当事件发生时,委托类就可以获取一些信息或者告知被委托类。

使用这种模式可以使得数据解耦。例如,一个展示数据列表的类应该只处理数据展示的逻辑,而不需要知道数据的类型或者数据之间的逻辑。这个视图对象应该有某个属性去处理数据和事件。它们各自被称为数据源和委托。

Objective-C中,实现这种模式的常用方法是使用协议这种语言特性,它被用于整个Cocoa框架。如果你使用了这种功能,你会发现你的代码非常健壮。

例如,有一个类是从网络获取数据的。这个类从远端服务器获得数据。服务器可能需要花费很多时间才会相应,如果一直处在等待中,这是一种坏的体验。所以,通常会使用委托模式,这个网络类会有一个协议对象,并且当数据返回时,回调这个对象。图4.1展示了这个概念;EOCDataModel对象是被委托类,EOCNetworkFetcher是委托类。EOCDataModelEOCNetworkFetcher执行一个异步任务,当任务执行完毕时,EOCNetworkFetcher调用这个委托。

Figure 4.1 一次委托回调的流程。注意委托对象并不需要一定是EOCDataModel实例,也可以是另外的实例。

使用Objective-C的协议可以很容易的实现这个模式。像图4.1这种情况,这个协议可能是这样定义的:

1
2
3
4
5
6
7
8
@protocol EOCNetworkFetcherDelegate
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didReceiveData:(NSData*)data;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didFailWithError:(NSError*)error;
@end

一个委托协议的命名通常是类名后面追加delegate,整个名字使用骆驼命名法。给你的委托协议使用这种命名法可以使任何使用者都感到熟悉。

类会使用协议声明一个属性用于存放被委托者。在我们的例子中, 定义属性的类就是EOCNetworkFetcher类。因此,这个类的接口是这样的:

1
2
3
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, weak) id <EOCNetworkFetcherDelegate> delegate;
@end

确保这个属性定义为weak而不是strong是非常重要的,因为它必须是非拥有关系。通常情况下,被委托者对象也会持有本对象。例如,一个对象想使用EOCNetworkFetcher,它将会持有EOCNetworkFetcher对象直到用完为止。如果属性使用strong特质持有被委托者对象,那么就会形成循环引用。因此,这个协议属性需要定义成weak或者unsafe_unretained,如果需要在释放时自动情况值,那么需要使用weak,如果不需要释放,那么就使用unsafe_unretained。它们的持有关系如图4.2。

Figure 4.2 持有关系图展示了不保留delegate属性,可以避免循环引用。

实现委托的办法是声明某个类遵循协议,然后实现任何你想使用的协议方法。你可以在接口文件中声明类遵循的协议,也可以在类扩展(看第27节)中实现。如果你想告诉别人你事先了某个协议那么在接口中实现;但是像委托这种情况,一般都是在类的内部使用。所以它通常像这样被声明在类扩展中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface EOCDataModel () <EOCNetworkFetcherDelegate>
@end
@implementation EOCDataModel
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didReceiveData:(NSData*)data {
/* Handle data */
}
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didFailWithError:(NSError*)error {
/* Handle error */
}
@end

通常,协议中的方法是可以选择实现的,因为被委托对象可能并不关心所有的方法。在这个例子中,DataModel类可能不关心错误的方法,所以它可能不会实现networkFetcher:didFailWithError:这个方法。为了指明可选方法,通过使用@optional关键字标注大部分或者所有的方法:

1
2
3
4
5
6
7
8
9
10
@protocol EOCNetworkFetcherDelegate
@optional
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didReceiveData:(NSData*)data;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didFailWithError:(NSError*)error;
@end

如果要在通过被委托对象调用可选方法,那么就要判断被委托对象是否能响应这个方法。在EOCNetworkFetcher这个例子中,它可能是这样写的:

1
2
3
4
5
6
NSData *data = /* data obtained from network */;
if ([_delegate respondsToSelector:
@selector(networkFetcher:didReceiveData:)]) {
[_delegate networkFetcher:self didReceiveData:data];
}

respondsToSelector:这个方法用于确认被委托者是否实现了某个方法。如果实现了,就调用它;如果没实现,什么也不做。这样的话,协议方法就是可选的了,并且不会因为没有实现某个方法而出问题。即使没有设置被委托对象,也是没有问题的,因为给空发送消息将会使得if语句的值为false

协议中的方法名也是很重要的。方法名应该准确描述当前发生了什么,以及为什么要处理此事件。在这个例子中,协议方法读起来非常清楚,一个存在的EOCNetworkFetcher对象刚刚接收到了一些数据。你应该通过协议方法将被委托对象传递出去,就像上面的例子一样,这样当有特殊情况时可以根据被委托实例进行区分。例如:

1
2
3
4
5
6
7
8
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher didReceiveData:(NSData*)data {
if (fetcher == _myFetcherA) {
/* Handle data */
} else if (fetcher == _myFetcherB) {
/* Handle data */
}
}

这里有两个被委托实例,所以要区分到底哪个实例接受了数据。如果没有传入被委托实例,那么同一时间只能处理一个网络请求,这样不是很好。

协议方法也可以用来从被委托者中获取消息。例如,EOCNetworkFetcher类可能想提供这样一个机制,当获取数据发生重定向时,那么将向被委托对象询问是否允许发生重定向。这个协议方法看起来是这样的:

1
- (BOOL)networkFetcher:(EOCNetworkFetcher*)fetcher shouldFollowRedirectToURL:(NSURL*)url;

这个例子解释了为什么这种模式叫做委托模式,因为是一个对象委托另一个对象去处理一些行为。

协议也可以提供一套接口给需要获取数据的类使用。这种委托模式被称为数据源模式,因为它的作用是给委托者提供数据。数据源模式中,信息从被委托者流向委托者;而正常的委托模式,信息是从委托者流向被委托者。图4.3展示了这个流程。

Figure 4.3 数据源模式中,信息从被委托者流向委托者;而在普通的委托模式,信息是从委托者流向被委托者。

例如,用户界面的列表对象使用数据源协议获取数据用于展示在列表中。列表视图还有一个委托协议用来处理用户操作。通过分隔数据源和委托协议,提供了一个清晰的接口,因为它们的逻辑也被分离了。另外,你可以使用一个对象处理数据源,另一个对象处理委托。然而,一般情况下,两者都是同一个对象。

如果数据源的方法和委托的方法大多数可选的,那么你将会写大量类似这样的代码:

1
2
3
4
if ([_delegate respondsToSelector:@selector(someClassDidSomething:)]) {
[_delegate someClassDidSomething];
}

检查被委托者是否实现了一个确定的方法是非常迅速的,但是如果一直这样做,那么除了第一次之外其余的是多余的。如果被委托对象本身没有发生变化,那么不太可能会突然开始响应或停止响应某个方法。因此,可以将被委托者是否能响应方法的结果缓存在协议中。例如,EOCNetworkFetcher类有一个被委托对象,它用于表示获取进度的回调方法,每当进度发生变化,就会调用被委托对象实现的协议方法。那么在这个生命周期中,这个方法将会被多次调用,并且每次都会检查是否相应这个方法。

将刚才说的方法加进协议中,目前协议中的定义是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
@protocol EOCNetworkFetcherDelegate
@optional
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didReceiveData:(NSData*)data;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didFailWithError:(NSError*)error;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
didUpdateProgressTo:(float)progress;
@end

这里新增了一个叫做networkFetcher:didUpdateProgressTo:的方法。缓存这个相应结果的最好办法是使用位段数据结构。这是一项很少使用的C语言特性,但在这里确实很合适的。它允许你定义一个确定的结构体位段,并且设置特定的值。看起来是这样的:

1
2
3
4
5
6
struct data {
unsigned int fieldA : 8;
unsigned int fieldB : 4;
unsigned int fieldC : 2;
unsigned int fieldD : 1;
};

在这个结构体中,fieldA使用8个二进制位,fieldB使用4个二进制位,fieldC使用2个二进制位,fieldD使用1个二进制位。所以fieldA可以表示0-255的数字,fieldD可以表示0或者1。如果创建的结构体中只有大小为1的位段,那么就能把许多布尔值塞入数据中了。以EOCNetworkFetcher类为例,你可以设置一个包含有位段的结构体为实例变量,结构体中的没一个变量代表一个协议方法。这个结构体是这样的:

1
2
3
4
5
6
7
8
9
10
11
@interface EOCNetworkFetcher () {
struct {
unsigned int didReciveData : 1;
unsigned int didFailWithError : 1;
unsigned int didUpdateProgressTo : 1;
} _delegateFlags;
}
@end

这里,我使用了类扩展去添加实例变量,具体描述在第27节;这个实例变量是一个结构体包含三个位段,每个位段代表一个协议方法。在EOCNetworkFetcher类中,可以像下面这样查询并设置结构体中的位段:

1
2
3
4
5
6
// Set flag
_delegateFlags.didReceiveData = 1;
// Check flag
if (_delegateFlags.didReceiveData) {
// Yes, flag set
}

这个结构体用来魂村被委托对象是否能相应某个指定方法的结果。实现缓存功能的代码可以写在delegate属性的setter方法中:

1
2
3
4
5
6
7
- (void)setDelegate:(id<EOCNetworkFetcher>)delegate {
_delegate = delegate;
_delegateFlags.didReceiveData = [delegate respondsToSelector:@selector(networkFetcher:didReceiveData:)];
_delegateFlags.didFailWithError = [delegate respondsToSelector:@selector(networkFetcher:didFailWithError:)];
_delegateFlags.didUpdateProgressTo = [delegate respondsToSelector:@selector (networkFetcher:didUpdateProgressTo:)] ;
}

这样每次调用delegate的相关方法时,就不需要检测被委托对象是否能响应指定的方法了,可以直接查询结构体中的标志:

1
2
3
4
if (_delegateFlags.didUpdateProgressTo) {
[_delegate networkFetcher:self didUpdateProgressTo:currentProgress];
}

如果多次调用判断方法,那么这个优化是有价值的。是否优化需要取决你的代码。你应该使用性能检测工具检测你的代码是否遇到性能瓶颈,如果需要优化可以使用类似技巧。如果频繁从数据源获取数据,那么该优化技术极大可能提高程序效率。

小结

  • 委托模式为对象提供了一套接口,使其可将相关事件告诉其他对象。
  • 在协议中把可能需要处理的事件定义成方法。
  • 当一个对象需要从别的对象获取数据时,使用委托模式。在这种情况下,该模式被称为数据源模式。
  • 如果需要,可以实现包含位段的结构体,用于缓存被委托者相应指定方法的结果。

使用便于管理的分类分散类的实现代码

随着许多方法放置在实现文件中,一个类很容易变得臃肿。有时候,这样是没问题的,即使你对它们进行了重构也并不能变得更好。在这种情况下,可以使用Objective-C的类别功能,将其分封在几个区域中,这对开发和调试都有好处。

比如有一个模型类代表人的信息。那么这个类就会有一些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSArray *friends;
- (id)initWithFirstName:(NSString*)firstName andLastName:(NSString*)lastName;
/* Friendship methods */
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;
- (BOOL)isFriendsWith:(EOCPerson*)person;
/* Work methods */
- (void)performDaysWork;
- (void)takeVacationFromWork;
/* Play methods */
- (void)goToTheCinema;
- (void)goToSportsGame;
@end

这个类的实现会包含长长的方法列表。如果有更多方法加进这个类,这个类会随着时间越来越难以管理。所以分隔这个类是常用的办法。例如,使用类别功能,上面的类可能这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, strong, readonly) NSArray *friends;
- (id)initWithFirstName:(NSString*)firstName andLastName:(NSString*)lastName;
@end
@interface EOCPerson (Friendship)
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;
- (BOOL)isFriendsWith:(EOCPerson*)person;
@end
@interface EOCPerson (Work)
- (void)performDaysWork;
- (void)takeVacationFromWork;
@end
@interface EOCPerson (Play)
- (void)goToTheCinema;
- (void)goToSportsGame;
@end

现在,类的每个不同部分都被划分到不同的类别中了。毫不奇怪,这项语言的特性被称作类别。在这个例子中,类的基础部分包括属性和初始化方法,所以声明在主实现中。附加的那些方法,根据不同功能,分封在不同的类别中。

你仍然可以将所有的声明和实现放在一个类中,但随着类别的增长,单一的实现文件也很容易变得难以管理。在这个例子中,类别也可以拥有它们自己的实现文件。例如,EOCPerson可以这样分隔实现:

  • EOCPerson+Friendship(.h/.m)
  • EOCPerson+Work(.h/.m)
  • EOCPerson+Play(.h/.m)

例如,Friendship分类可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// EOCPerson+Friendship.h
#import "EOCPerson.h"
@interface EOCPerson (Friendship)
- (void)addFriend:(EOCPerson*)person;
- (void)removeFriend:(EOCPerson*)person;
- (BOOL)isFriendsWith:(EOCPerson*)person;
@end
// EOCPerson+Friendship.m
#import "EOCPerson+Friendship.h"
@implementation EOCPerson (Friendship)
- (void)addFriend:(EOCPerson*)person {
/* ... */
}
- (void)removeFriend:(EOCPerson*)person {
/* ... */
}
- (BOOL)isFriendsWith:(EOCPerson*)person {
/* ... */
}
@end

这个类已经被分隔成多个易于管理的代码块,并且利于单独检查。使用分类机制之后,如果要使用分类方法,要记得将有关的类别头导入EOCPerson的头文件。虽然稍微有点麻烦,但这是使代码易于管理的好办法。

即使类不是很大,也可以通过分类机制将不同模块的代码放入不同的功能域。在Cocoa框架中有一个这样的例子,那就是NSURLRequest和它的可变版本NSMutableURLRequest。这个类经常使用HTTP请求获取网络数据,但是它也能使用别的协议。然而,与标准的URL相比,HTTP请求需要额外的附加信息,例如HTTP方法(Get、Post等)和HTTP头。

但是NSURLRequest不是易于子类化的,因为它包含了一套操作CFURLRequest结构体的C函数,以及所有的HTTP方法。所以给NSURLRequest添加了一个叫做NSHTTPURLRequest的类别,用于放置HTTP相关方法,还添加了一个叫做NSMutableHTTPURLRequest的类别,用于放置可变版本的方法。这样所有的CFURLRequest的方法都封装在同一个类里面了,但是仍要将HTTP方法单独拆分,不然使用者可能会想,为什么FTP协议的请求可以设置HTTP方法?

另一个使用类别的原因是便于调试:对于某个分类中的所有办法来说,分类名称会出现在其符号中。例如,addFriend:输出的符号名是这样的:

1
-[EOCPerson(Friendship) addFriend:]

当它在调试器的回溯中出现时,通常是这样的:

1
frame #2: 0x00001c50 Test'-[EOCPerson(Friendship) addFriend:] + 32 at main.m:46

在回溯中的类别名称可以很容易的区分类方法属于哪个功能区,对私有方法特别有用。在这种情况下,可能会创建一个叫做private的类别包含这些私有方法。这种类别的方法通常只用在类或者框架的内部。如果使用者通过回溯看到了这个信息,就知道不该直接调用这个私有方法。这算是一种自我描述式代码的办法。

当你决定创建一个库给别人使用时,私有的类别是非常有用的。通常,有一些方法不应该暴漏在外部,但是却适合在类库内部使用。在这种情况下,创建一个私有分类是非常有用的,因为可以在任何需要使用的内部导入它的头文件。如果类别头文件不随着类库一并公开,那么类库的使用者是不会知道这些私有类别的。

小结

  • 使用类别分隔某个类,使其易于管理。
  • 创建一个私有类别用于隐藏那些不该暴漏的方法。

总是为第三方类的分类名称加前缀

类别通常被用做给已存在的但无源码的类添加功能。这是一个非常有用的功能,但是这样做也很容易出问题。产生问题的原因是如果类本身有某个方法,而分类又实现了这个方法,那么类本身的方法将会被覆盖。在运行时会将分类中的每个方法加入到类的方法列表中。如果类中有这个方法,而分类又实现一次,那么分类中的方法就会覆盖类中的。实际上这个问题可能会发生不止一次,因为某个类别覆盖了类的本身实现,别的类别覆盖了上个类别。那么调用时将会调用最后一个覆盖的方法。

例如,你创建一个NSString的类别,并提供一些用于处理HTTP URL的方法。你可能会这样定义:

1
2
3
4
5
6
@interface NSString (HTTP)
// Encode a string with URL encoding
- (NSString*)urlEncodedString;
// Decode a URL encoded string
- (NSString*)urlDecodedString;
@end

这看起来非常好啊,但是考虑一下如果另一个类别也给NSString添加了同样的方法。第二个类别也添加了一个叫做urlEncodedString的方法,但是实现却与你的类别有些微差异。如果那个类别在你的类别之后加载,那么调用方法时将会调用第二个类别的。这会导致你的代码不能正确的工作,并且会得到意料之外的结果。这种问题是难以排查的,因为你不知道实际上你的方法并没有运行。

解决这种问题,一般的做法是使用命名空间区分类别和类别中的方法。而Obejective-C中命名空间的做法是给相关名称加上前缀。就像之前给类添加前缀一样,给分类选择前缀也要选择恰当才行。通常,前缀名与你的应用名或者库名相同。因此,NSString类别加上ABC前缀之后大概是这样的:

1
2
3
4
5
6
7
8
@interface NSString (ABC_HTTP)
// Encode a string with URL encoding
- (NSString*)abc_urlEncodedString;
// Decode a URL encoded string
- (NSString*)abc_urlDecodedString;
@end

从技术上讲,并不一定需要把类别名称加前缀。两个同名类别不会出现问题。但是这是不好的,因为编译器会发出这样的编译警告:

1
warning: duplicate definition of category 'HTTP' on interface 'NSString'

即使这样也可能会有别的类别覆盖你的方法,但是这样做可以大大减少这种情况,因为别的类和你使用相同的前缀名是不太可能的。这样做也能避免类的开发者以后更新类的时候覆盖你的方法。例如,如果苹果给NSString添加了urlEncodedString方法,而你的方法又没有增加前缀,那么你将覆盖苹果的方法。这是不合适的,因为别的NSString类的使用者想得到的结果是苹果的代码输出,而非你的返回结果。或者苹果实现的方法带有一些别的效果,你的方法如果覆盖了苹果的方法并且没有响应的效果,那么就会产生难以排查的问题。

此外要记住的是你使用分类给某个类添加的方法,该类的每一个实例都可以调用。如果你给系统提供的类添加功能,例如NSString、NSArra、NSNumber,那么即使这些类的实例不是由你的代码初始化出来的,它们也可以调用你添加的方法。如果你无意中把自己类别中的方法起的和其他分类或者第三方库一样,那么将会产生难以预料的问题,因为你以为执行的是自己的方法,但是实际上却不是。同样的,使用类别故意覆盖方法也是不好的,尤其是当你的方法是以类库形式提供给别人使用时,而他们又要依赖系统中的功能。如果其他开发者覆盖相同的方法,那么就更不能确定到底运行的是哪个类别的方法了。这又一次说明了给类别方法使用命名空间的重要性。

小结

  • 向第三方类中添加分类时,总应给你的分类加上相应的前缀。
  • 向第三方类中添加分类时,总应给你分类中的方法加上相应的前缀。

避免在类别中使用属性

属性是用来封装数据的(看第6节)。从技术上讲你可以在类别中声明属性,但你不应该这样做。理由是,除了类扩展(看第27节)这种特殊类别会产生实例变量并加入类中,普通类别中的属性并不会产生实例变量。

在阅读了第24节之后,你决定将一个代表人的类的实现分隔进不同的类别中。你可能会创建一个朋友的类别用来存放所有操作朋友属性的方法。如果你不知道“应该避免在类别中使用属性这个问题”,你可能会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName
andLastName:(NSString*)lastName;
@end
@implementation EOCPerson
// Methods
@end
@interface EOCPerson (Friendship)
@property (nonatomic, strong) NSArray *friends;
- (BOOL)isFriendsWith:(EOCPerson*)person;
@end
@implementation EOCPerson (Friendship)
// Methods
@end

如果你就这样编译它,那么你会得到两个编译警告:

1
2
3
4
5
6
7
warning: property 'friends' requires method 'friends' to be
defined - use @dynamic or provide a method implementation in
this category [-Wobjc-property-implementation]
warning: property 'friends' requires method 'setFriends:' to be
defined - use @dynamic or provide a method implementation in
this category [-Wobjc-property-implementation]

这个警告的意思是属性不能自动合成变量,所以需要提供存取方法去实现属性。或者,你可以使用@dynamic声明属性,告诉编译器你会在运行时添加,而不是编译时。如果你在运行时使用消息转发机制(看第12节)拦截消息,并且提供实现这也是个办法。

关于为什么类别不能生成变量,你可以去看关联对象(对,去看第10节)。例如,在类别中,你可能需要这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#import <objc/runtime.h>
static const char *kFriendsPropertyKey = "kFriendsPropertyKey";
@implementation EOCPerson (Friendship)
- (NSArray*)friends {
return objc_getAssociatedObject(self, kFriendsPropertyKey);
}
- (void)setFriends:(NSArray*)friends {
objc_setAssociatedObject(self,
kFriendsPropertyKey,
friends,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@end

这样做也可以使代码正常工作,但这不是一个好主意。这是一个重复且容易在内存管理上出错的做法,因为你很容易忘记属性的实现。例如,你修改了它关联属性的内存管理语义,那么你总是需要记着修改setter方法中的内存管理语义。尽管这不是坏的解决办法,但我也不推荐这样做。

并且,你可能想让返回的friends数组是可变的。你可以对传入的数组使用mutableCopy,但这样又会产生另一个编程问题。综上所述,在主文件中定义属性是比在类别中更清楚易懂的。

在这个例子中,正确的做法是将所有的属性定义在主文件中。所有的数据封装都应该定义在主文件,那里是唯一可以定义变量的地方。因为属性其实只是实例变量以及存取方法的语法糖。类别的唯一用途就是给类添加额外的方法而不是存储数据。

话虽如此,有时也可以在类别中使用只读属性。例如,你可能想创建一个NSCalendar的分类,并且返回一个包含所有月份字符串的数组。因此这个方法不需要访问任何属性也不需要产生实例变量,你可以像下面这样定义它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@interface NSCalendar (EOC_Additions)
@property (nonatomic, strong, readonly) NSArray *eoc_allMonths;
@end
@implementation NSCalendar (EOC_Additions)
- (NSArray*)eoc_allMonths {
if ([self.calendarIdentifier isEqualToString:NSGregorianCalendar]) {
return @[@"January", @"February",
@"March", @"April",
@"May", @"June",
@"July", @"August", @"September", @"October", @"November", @"December"];
} else if ( /* other calendar identifiers */ ) {
/* return months for other calendars */
}
}
@end

这样属性就无需生成实例变量了,因为所有需要的方法(仅仅在属性是只读的情况下)都已经实现。因此,编译器也不会有任何警告。即使在这种情况下,也最好避免使用属性。属性要表达的意思是它持有某些数据。属性是用来封装数据的。在这个例子中,你应该声明一个方法去替代属性的使用:

1
2
3
4
5
@interface NSCalendar (EOC_Additions)
- (NSArray*)eoc_allMonths;
@end

小结

  • 保持所有属性都声明在主文件中。
  • 除非是类扩展这种特殊类别,否则类别中尽量不要定义属性,但可以定义存取方法。

使用类扩展隐藏类实现细节

通常,你的类里面的很多方法和实例变量是只想给内部使用的。你可以表面上暴漏它们但将它们标记为私有的,那么使用者就不会使用这些了。最重要的是,在Objective-C中是没有私有变量或私有方法的,这完全因为Objective-C的动态消息派发系统(看第11节)。然而,只暴漏需要暴漏的公共方法才是正确的原则,但是我们应该在哪里声明不该暴漏的属性和方法呢?这时就需要特殊的类别,即类扩展了。

类扩展不像普通的类别,它必须定义在类的实现文件中。重要的是,它是仅有的允许声明实例变量的类别。而且,这个类别不需要特殊的实现。任何定义在这里的方法和属性都能正常的出现在类的使用中。不像其他类别,这个特殊的类别也没有名字。一个EOCPerson类的类扩展大概是这样的:

1
2
3
@interface EOCPerson ()
// Methods here
@end

为什么这个类别有用?它有用是因为这里可以定义实例变量和方法。这种可能性来源于稳定的ABI(看第6节),这意味着不需要知道对象的大小也可以使用它。因此,内部使用的实例变量不需要定义在公共接口中,这样类的使用者也不会知道类的内存布局。因此,使用类扩展给类添加实例变量等同于在类实现中添加变量。为了这样做,你只需要在类扩展右侧加上大括号,并将变量放置在那里。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface EOCPerson () {
NSString *_anInstanceVariable;
}
// Method declarations here
@end
@implementation EOCPerson {
int _anotherInstanceVariable;
}
// Method implementations here
@end

这样做有什么意义吗?你可以在公共接口中定义实例变量啊。但是这样做可以将实现细节隐藏在类扩展或者实现块中,使其仅为本类使用。即使你在公共接口中将其标位private,但依然泄露了实现细节。例如,你不想别人知道你在类的内部使用了某个秘密的类。假如你在类中使用了秘密的类并且将其定义在公共接口中,那么看起来像这样:

1
2
3
4
5
6
7
8
9
#import <Foundation/Foundation.h>
@class EOCSuperSecretClass;
@interface EOCClass : NSObject {
@private
EOCSuperSecretClass *_secretInstance;
}
@end

这个叫做EOCSuperSecretClass的类已经泄露了。你可以不将其类型信息表现出来并使用id代替。但这样做是不好的,因为你在内部使用时,无法获得编译器的任何帮助。仅仅因为不想暴漏类型就要失去编译器的帮助?类扩展可以很好的达成这个目标。现在它的定义像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
// EOCClass.m
#import "EOCClass.h"
#import "EOCSuperSecretClass.h"
@interface EOCClass () {
EOCSuperSecretClass *_secretInstance;
}
@end
@implementation EOCClass
// Methods here
@end

同样的,实例变量也可以定义在类实现块中,在语义上它跟定义在类扩展中是一样的。我更推荐将其添加在类扩展中,因为它可以保持定义地方的一致性。你可能已经将属性定义在这里了,所以这是一个好的添加额外变量的地方。这些变量并不是真正意义上的私有,在运行时通过某些方法依然可以得到它们,但在我们的目的上,它们是私有的。而且,如果它们没有声明在公共头文件中,如果你的代码是某个库的一部分,那它们会隐藏的更深。

另一个使用类扩展的地方是在你混写Objetive-CC++代码时。在混写环境下,你所写的代码两种语言都可以使用。游戏通常出于性能和移植性的考虑,一般会使用C++。有时,你可能需要使用C++,因为你使用了一个第三方库并且那个库是用C++写的。这种情况下,类别同样可以处理。假设在你使用类别之前,你可能会这样写:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
#include "SomeCppClass.h"
@interface EOCClass : NSObject {
@private
SomeCppClass _cppClass;
}
@end

这个类的实现文件应该叫做EOCClass.mm,这个.mm告诉编译器应该允许混编,没有这个符号,就无法导入SomeCppClass.h了。然而这样会把整个SomeCppClass类导入进来,因为编译器需要知道_cppClass的全部信息。所以任何包含了EOCClass.h的类也需要能进行混合编译,因为它也包含了SomeCppClass.h。这很容易导致失去控制使得整个项目都需要进行混合编译。这么做完全可以,但是我不推荐这种做法,特别是当你的代码以第三方库的形式给别人使用时,这会让使用者需要给所有的文件重新命名并加上.mm后缀。

你可能会使用前向声明的方法导入这个C++类,并将其实例变量声明为一个指针,如下:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
class SomeCppClass;
@interface EOCClass : NSObject {
@private
SomeCppClass *_cppClass;
}
@end

这个实例变量现在需要声明为一个指针,如果它不是指针,编译器无法得知它的大小,这将会产生一个错误。指针的大小是固定的,所以需要告诉编译器它是一个指针。这时问题又出现了,别的导入了EOCClass的类,当它们发现class这个关键字时,这是一个C++关键字,它们也需要能进行混编才能编译通过。这是没有必要的,因为这是一个私有变量,别的类根本不关心它的存在。这时,类扩展又一次派上用场了。像下面这样使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
// EOCClass.mm
#import "EOCClass.h"
#include "SomeCppClass.h"
@interface EOCClass () {
SomeCppClass _cppClass;
}
@end
@implementation EOCClass
@end

现在头文件从C++中脱离出来了,而使用了这个头文件的其他类也无需担心C++了,现在C++的使用是秘密了。在系统的一些类库中也使用了这种模式,例如WebKit,这是一个网页浏览器框架,它同样是使用C++写的并且提供了只有Objective-C语言的接口。这个模式在CoreAnimation中也有用到,内部都是使用C++书写,给外部使用的接口都是Objective-C

类别的另一个用途是在公共接口中提供只读属性在内部对属性进行修改。通常你会调用属性的setter方法而不是直接调用实例变量,是因为你不知道是否有其他对象监听了你的属性,而直接调用实例变量不会触发KVO。出现在类扩展或者类别中的属性,必须与主文件中的属性特质一致,但是只读特质可以改变为读写特质。例如,下面类的公共文件如下:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName lastName:(NSString*)lastName;
@end

通常类扩展中会修改这两个属性的读写特质:

1
2
3
4
5
6
@interface EOCPerson ()
@property (nonatomic, copy, readwrite) NSString *firstName;
@property (nonatomic, copy, readwrite) NSString *lastName;
@end

一切准备妥当了。现在EOCPerson的实现文件可以通过setFirstName:setLastName:方法或者点语法自由的设置数据了。这样做也可以保证外部是不可变的,内部仍可根据需要修改。这样封装的数据就由类本身的实例进行控制,外部就无法控制了。第18节有更多关于这个话题的信息。请注意,现在有一个潜在的问题,如果观察者正在读取这个值,而内部则正在对这个值进行修改,则有可能引发竞争条件。合理的使用同步(看第41节)机制将减少问题的发生。

另一个使用类扩展的地方是在其中声明私有方法,即只在类内部使用的方法。因为它表示这些方法都是在内部使用的。看起来是这样的:

1
2
3
4
5
@interface EOCPerson ()
- (void)p_privateMethod;
@end

这个地方使用前缀的原因是第20节讲述了使用前缀标示私有方法。在最新的编译器版本,不需要严格遵守这条约定。但是,在类似的类扩展中将方法标示出来仍然是一个好主意。我经常像这样先把方法原型写出来。然后再去考虑实现这些方法。这是一个提高类可读性的办法。

最后,类扩展也是一个遵循协议私有化的好地方。通常,你不需要在公共接口中泄露你遵守了哪些确定的协议,可能因为那个协议是私有API的一部分。例如,EOCPerson类遵循一个叫做EOCSecretDelegate的协议。如果在公共接口中声明,它大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
#import <Foundation/Foundation.h>
#import "EOCSecretDelegate.h"
@interface EOCPerson : NSObject <EOCSecretDelegate>
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
- (id)initWithFirstName:(NSString*)firstName lastName:(NSString*)lastName;
@end

你可能认为你可以仅仅前向声明这个协议而不是导入它(或者在头文件定义它)。你使用前向声明代替导入:

1
@protocol EOCSecretDelegate;

然而任何一个导入了这个头文件的地方,编译器都会有下面的警告:

1
warning: cannot find protocol definition for 'EOCSecretDelegate'

这个警告是因为编译器知道不会有机会知道协议中到底定义了什么方法。但是它是一个私有的内部协议,你甚至没必要泄露它的名字。类扩展再次救场!替代EOCPerson在公共接口中遵循EOCSecretDelegate协议,你可以在类扩展中这样写:

1
2
3
4
5
6
7
8
9
10
#import "EOCPerson.h"
#import "EOCSecretDelegate.h"
@interface EOCPerson () <EOCSecretDelegate>
@end
@implementation EOCPerson
/* ... */
@end

公共接口对协议的遵循被移除。私有协议不在暴漏,使用者如果不深入查找也不知道它的存在。

小结

  • 使用类扩展给类添加实例变量。
  • 如果在公共接口中声明的属性需要在内部进行修改,使用类扩展修改它的读写特质。
  • 在类扩展中声明私有方法。
  • 使用类扩展去遵循你想私有的协议。

通过协议提供匿名对象

协议定义了一系列遵循对象应该实现或者必须实现的方法。因此我们可以通过协议隐藏自己所写的API细节,将返回的对象设计为遵从此协议的id类型。这样就不会泄露API中特定类的名字了。当你想有许多不同类的行为时这是非常有用的,并且也不需要指定特定的类。例如,许多类并不能以标准的继承方式实现时,因此它们没有共同的基类。

这个概念被称为匿名对象,它并不像别的语言中的匿名对象。别的语言的匿名对象是指通过一个类的内联函数创建的没有名字的对象。在Objective-C中,它并不是这个意思。在第23节,委托和数据源已经展示了匿名对象的使用。例如,委托的属性可能这样定义:

1
@property (nonatomic, weak) id <EOCDelegate> delegate;

这个属性的类型是id<EOCDelegate>,因此这个类的对象可以是任何东西;即使它不继承自NSObject也行。只要它遵循EOCDelegate协议。对具有此属性的类,这个就是匿名对象。如果你想知道匿名对象的类型信息也是可以的,只要在运行时检查对象的类型就可以了。但是这样做不是个好习惯,因为既然选择了这种属性类型就是不需要关心类型信息。

NSDictionary是这个概念的另一个例子。字典的key的内存管理语义是当设置值时拷贝它。因此,在一个可变数组中,设置一组键值对的方法如下:

1
- (void)setObject:(id)object forKey:(id<NSCopying>)key

key参数的类型是id<NSCopying>,因为它只需要任何遵循NSCopying协议的对象,只要能成功接收拷贝的消息就可以当做key。这个key参数也可以是匿名的。就像刚才的委托属性,字典也不关心key的类并且它也不需要。它只需要知道key这个参数可以接收拷贝信息即可。

使用匿名对象的另一个例子是当一个对象是从一个库返回时,这个库是用于处理数据库连接的。你可能不想泄露处理数据库的类,因为它可能不是同一个数据库。这些方法不是来自一个同样的基类,你只能强制它们返回id类型。但是,你可以创建一个协议并且声明通用的方法,让所有的处理数据库连接的类声明并遵循它。这个协议看起来是这样的:

1
2
3
4
5
6
7
8
@protocol EOCDatabaseConnection
- (void)connect;
- (void)disconnect;
- (BOOL)isConnected;
- (NSArray*)performQuery:(NSString*)query;
@end

然后你创建一个处理数据库的单例类,用以提供数据库连接。它的接口大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
#import <Foundation/Foundation.h>
@protocol EOCDatabaseConnection;
@interface EOCDatabaseManager : NSObject
+ (id)sharedInstance;
- (id<EOCDatabaseConnection>)connectionWithIdentifier: (NSString*)identifier;
@end

这样,这个类就可以处理数据库连接而不暴漏来自不同的框架不同的类并且都返回同样的方法。所有使用这个API的人只要关心这个对象可以执行连接和断开连接就可以了。这一点很重要。在这个例子中,后端代码去处理数据库连接可以使用不同的第三方库去连接不同的数据库。(例如,MySQL, PostgreSQL)。由于这些类来自不同的第三方库,让它们继承同一个基类是不可能的事情。所以可以把这些第三方类简单的包装起来,使匿名对象成为其子类,并遵循EOCDatabaseConnection协议。然后可以通过connectionWithIdentifier:方法返回这些类对象。在开发后续呢版本时,无需改变公共API即可切换后端的实现类。

当你想表达的对象类型是不重要的时候,你也可以选择匿名类型,但更重要的是对象有没有实现某些方法。即使你实现中的某个类型总是确定的,你可能仍想使用匿名类型来表达它类型是不重要的。在CoreData框架中有一个这种用法的例子。这个类叫做NSFetchedResultsController,它用来处理查询CoreData数据库的结果,如果有需要,处理时还会把数据分区。在负责查询结果的控制器上有一个叫做sections的字段用来处理分区。它是一个遵循NSFetchedResultsSectionInfo协议的对象数组而不是一个具体类型的数组。使用控制器去获取分段信息就像这样:

1
2
3
4
5
NSFetchedResultsController *controller = /* some controller */;
NSUInteger section = /* section index to query */;
NSArray *sections = controller.sections;
id <NSFetchedResultsSectionInfo> sectionInfo = sections[section];
NSUInteger numberOfObjects = sectionInfo.numberOfObjects;

sectionInfo是一个匿名对象。在制作API时要把通过对象获取分段信息这个功能清晰的展示出来。在代码内部,这个对象可能是结果控制器通过内部状态创建出来的。也没必要把这个状态暴漏在公共接口总,因为这个控制器的使用者并不关心数据段是如何存储的。它们需要的只是能通过这个控制器去查询数据。sections数组中返回的内部状态对象就是遵循了某个协议的匿名对象。使用者只要知道它实现了一些方法就可以了,这样也隐藏了对象的其余实现细节。

小结

  • 协议可以在某种程度上提供匿名对象。对象的类型可以降低为遵循了某协议并实现了某些方法的id类型。
  • 当类型信息应该被隐藏时,使用匿名对象。
  • 当类型信息不重要时,并且对象实现了该实现的方法(某个协议定义的),那么可匿名对象表示。